Skip to content

fix(cable): send the tunnel Shutdown message on close - #310

Open
p-c-h-b wants to merge 3 commits into
linux-credentials:masterfrom
p-c-h-b:cable-send-shutdown-on-close
Open

p-c-h-b wants to merge 3 commits into
linux-credentials:masterfrom
p-c-h-b:cable-send-shutdown-on-close

Conversation

@p-c-h-b

@p-c-h-b p-c-h-b commented Sep 23, 2026

Copy link
Copy Markdown

Problem

CableChannel::close() is a TODO:

async fn close(&mut self) {
    // TODO Send CableTunnelMessageType#Shutdown and drop the connection
}

and Drop for CableChannel aborts the connection task. So after a hybrid (caBLE v2) ceremony the authenticator never receives the Shutdown control message; it sees the tunnel drop instead. Google Play services on a Pixel 9 Pro (Android 16) then ends successful ceremonies on a "Something went wrong" screen. The client and the relying party both succeed. Logcat at the end of a successful get-assertion:

Fido [CtapMessageProcessor] Success get-assertion result is sent to client.
Fido [HybridAuthenticatorController] Hybrid authenticator completed with error: …
Fido [WebSocketReceiver] IOException  java.net.SocketException: Socket closed

Found while integrating libwebauthn 0.10.0 as the hybrid client of a desktop app.

Change

This follows what Chromium's FidoTunnelDevice does: on close it encrypts and sends a one-byte kShutdown message, then waits for the peer to close.

  • close() signals the connection task through a new oneshot, once the tunnel is connected, then waits up to 3 s for the task to finish. Before the tunnel is up there is nothing to shut down, and it returns immediately. Chromium waits up to three minutes for the peer; a short bound keeps close() from stalling a caller.
  • The connection task's select loop gets a shutdown branch. It sends Shutdown (type byte 0) through the same pad/frame/encrypt path as CTAP requests, which is factored out of connection_send as send_tunnel_message. It then waits up to 2 s for the peer to close its side.
  • Inbound: CableTunnelMessage::from_slice rejected every empty payload, so a bare Shutdown from the peer (the type byte alone, as Chromium and phones send it) failed as InvalidFraming. Shutdown may now be empty; the other types still require a payload.

Drop is unchanged (it still aborts): a caller that wants a clean ending calls close().

Tests

In transport::cable::protocol::tests:

  • a bare Shutdown parses, and other empty messages still do not;
  • a Shutdown sent with send_tunnel_message decrypts, on the other side of a Noise pair, to exactly [0x00];
  • CTAP frames still round-trip through the shared path.

cargo test -p libwebauthn --lib transport::cable (41 passed), cargo clippy -p libwebauthn --all-targets -- -D warnings and cargo fmt --all -- --check are clean on this branch.

On hardware, the same patch applied to 0.10.0 is in use against Google Play services; the phone is expected to end on its success screen instead of "Something went wrong".

`CableChannel::close()` was a TODO, and dropping the channel aborts the
connection task, so the authenticator never received the caBLE Shutdown
control message: it saw the tunnel drop instead. Google Play services then
ends even a successful hybrid ceremony on a "Something went wrong" screen.

Close the session the way Chromium's `FidoTunnelDevice` does (it encrypts
and sends a one-byte `kShutdown` message, then waits for the peer to
close):

- `close()` signals the connection task once the tunnel is connected, then
  waits up to 3 s for it to finish; before the tunnel is up there is
  nothing to shut down and it returns at once.
- The connection task sends Shutdown (type byte 0, padded and encrypted
  through the same path as CTAP frames), then waits up to 2 s for the peer
  to close its side.
- Inbound, a bare Shutdown (the type byte with no payload, as Chromium and
  phones send it) is accepted instead of failing as `InvalidFraming`.

Tests cover the bare-Shutdown parse, the Shutdown frame on the wire
(decrypts to exactly `[0x00]`), and CTAP frames through the shared path.
Drives the connection loop with a scripted peer: post-handshake
message, a pending getAssertion, then close. The only frame after the
request is an encrypted Shutdown, and the task ends cleanly once the
peer closes its side.
@p-c-h-b

p-c-h-b commented Sep 23, 2026

Copy link
Copy Markdown
Author

Tested on hardware (Pixel 9 Pro, Google Play services): with this change, successful get-assertion and make-credential ceremonies end on the phone's success screen instead of "Something went wrong".

I added a test: the connection loop is closed while a getAssertion is still pending, and the only frame after the request is one encrypted Shutdown.

One observation for whoever uses close() to cancel. If the Shutdown arrives while the phone is still processing (for example, its credential chooser is open), Google Play services closes the tunnel at once and shows "Generic hybrid error: EOF_WHILE_PROCESSING". The caBLE v2 message set (Shutdown / CTAP / Update) has no cancel message, and Chromium behaves the same way: FidoTunnelDevice::Cancel is a no-op, and its teardown sends the same kShutdown. So I have not tried to special-case it here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant